/**
 * Copyright (c) 2010 Daniel Murphy
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
/**
 * Created at 2:19:39 AM, Mar 12, 2010
 */
package com.dmurph.mvc;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.dmurph.mvc.monitor.EventMonitor;
import com.dmurph.mvc.monitor.LoggingMonitor;
import com.dmurph.mvc.monitor.WarningMonitor;
import com.dmurph.mvc.tracking.ICustomTracker;
import com.dmurph.mvc.tracking.ITrackable;
import com.dmurph.tracking.JGoogleAnalyticsTracker;

/**
 * This stores all the listener information, dispatches events to the
 * corresponding listeners. To dispatch events use {@link MVCEvent#dispatch()}
 * .</br> </br> Also, look at {@link #splitOff()}. To set up Google analytics,
 * call {@link #setTracker(JGoogleAnalyticsTracker)}, or implement
 * {@link ICustomTracker} in your events to be tracked, and then any event that
 * implements {@link ITrackable} will be tracked. If
 * {@link ITrackable#getTrackingCategory()} or
 * {@link ITrackable#getTrackingAction()} returns <code>null</code>, then it
 * will be ignored.
 * 
 * @author Daniel Murphy
 */
public class MVC extends Thread {

	private static final Logger log = LoggerFactory.getLogger(MVC.class);

	private static final ThreadGroup mvcThreadGroup = new ThreadGroup(
			"MVC Thread Group");
	private static final ArrayList<MVC> mvcThreads = new ArrayList<MVC>();
	private static final HashMap<String, List<IEventListener>> listeners = new HashMap<String, List<IEventListener>>();
	private static final Queue<MVCEvent> eventQueue = new LinkedList<MVCEvent>();

	private static final Object trackerLock = new Object();
	private volatile static JGoogleAnalyticsTracker tracker = null;
	private static final Object monitorLock = new Object();
	private volatile static IGlobalEventMonitor monitor = new LoggingMonitor();
	private static final Object mainThreadLock = new Object();
	private volatile static MVC mainThread;
	private volatile static String currKey = null;

	private volatile boolean running = false;
	private final int threadCount;

	private Iterator<IEventListener> currEventList;
	private MVCEvent currEvent;

	private MVC(int argNum) {
		super(mvcThreadGroup, "MVC Thread #" + argNum);
		threadCount = argNum;
		mvcThreads.add(this);
	}

	private MVC(int argNum, Iterator<IEventListener> currEventList,
			MVCEvent currEvent) {
		this(argNum);
		this.currEvent = currEvent;
		this.currEventList = currEventList;
	}

	public static void setTracker(JGoogleAnalyticsTracker argTracker) {
		synchronized (trackerLock) {
			tracker = argTracker;
		}
	}

	public static JGoogleAnalyticsTracker getTracker() {
		return tracker;
	}

	/**
	 * Adds a listener for the given event key. If the listener is already
	 * listening to that key, then nothing is done. On the rare occurrence that
	 * the key is being dispatched at the same time by the mvc thread, this call
	 * will wait till all the events of that key are dispatched before adding
	 * and returning. If that happens and the thead making this call is also the
	 * mvc thread, (a listener for a key adds another listener for the same
	 * key), then a runtime exception is thrown.
	 * 
	 * @param argKey
	 * @param argListener
	 */
	public static void addEventListener(String argKey,
			IEventListener argListener) {
		if (argKey == null) {
			throw new RuntimeException("Key cannot be null");
		}

		synchronized (listeners) {
			synchronized (mainThreadLock) {
				if (argKey.equals(currKey)
						&& Thread.currentThread() == mainThread) {
					throw new RuntimeException(
							"Cannot add a listener to the same key that's being dispatched");
				}
			}
			List<IEventListener> fifo;
			if (listeners.containsKey(argKey)) {
				// return if we're already listening
				if (listeners.get(argKey).contains(argListener)) {
					log.debug("We already have that listener here", argListener);
					return;
				}
				fifo = listeners.get(argKey);
			} else {
				fifo = new ArrayList<IEventListener>();
				listeners.put(argKey, fifo);
			}
			fifo.add(argListener);
		}
	}

	/**
	 * Checks to see if the listener is listening to the given key.
	 * 
	 * @param argKey
	 * @param argListener
	 * @return
	 */
	public static boolean isEventListener(String argKey,
			IEventListener argListener) {
		if (argKey == null) {
			throw new RuntimeException("Key cannot be null");
		}

		synchronized (listeners) {
			if (!listeners.containsKey(argKey)) {
				return false;
			}

			List<IEventListener> stack = listeners.get(argKey);
			return stack.contains(argListener);
		}
	}

	/**
	 * Gets a copy of the listeners for the given event key.
	 * 
	 * @param argKey
	 * @return
	 */
	public static LinkedList<IEventListener> getListeners(String argKey) {
		if (argKey == null) {
			throw new RuntimeException("Key cannot be null");
		}

		synchronized (listeners) {
			if (listeners.containsKey(argKey)) {
				return new LinkedList<IEventListener>(listeners.get(argKey));
			} else {
				return new LinkedList<IEventListener>();
			}
		}
	}

	/**
	 * removes a listener from the given key.
	 * 
	 * @param argKey
	 * @param argListener
	 * @return true if the listener was removed, and false if it wasn't there to
	 *         begin with
	 */
	public static boolean removeEventListener(String argKey,
			IEventListener argListener) {
		if (argKey == null) {
			throw new RuntimeException("Key cannot be null");
		}

		synchronized (listeners) {
			synchronized (mainThreadLock) {
				if (argKey.equals(currKey)
						&& Thread.currentThread() == mainThread) {
					throw new RuntimeException(
							"Cannot remove a listener to the same key that's being dispatched.  Return false instead.");
				}
			}

			if (listeners.containsKey(argKey)) {
				List<IEventListener> stack = listeners.get(argKey);
				return stack.remove(argListener);
			} else {
				return false;
			}
		}
	}

	/**
	 * Adds an event to the dispatch queue for the MVC thread. Used by
	 * {@link MVCEvent#dispatch()}.
	 * 
	 * @param argEvent
	 */
	protected static void dispatchEvent(MVCEvent argEvent) {
		boolean hasListeners;
		synchronized (listeners) {
			hasListeners = listeners.containsKey(argEvent.key);
		}

		if (hasListeners) {
			synchronized (eventQueue) {
				eventQueue.add(argEvent);
				eventQueue.notify();
			}

			if (!isDispatchThreadRunning()) {
				startDispatchThread();
			}
		} else {
			synchronized (monitorLock) {
				if (monitor != null) {
					try {
						monitor.noListeners(argEvent);
					} catch (Exception e) {
						log.error("Exception caught from monitor", e);
					}
				}
			}
		}
	}

	/**
	 * Split off the current MVC thread, all queued events and future event
	 * dispatches are handled by a new MVC thread, while this one runs to
	 * completion. If the thread calling this is not the current core MVC
	 * thread, then an exception is thrown
	 * 
	 * @throws IllegalThreadException
	 *             if the thread calling this is not an MVC thread
	 * @throws IncorrectThreadException
	 *             if the MVC thread calling this is not the main thread, e.g.
	 *             it has already split off.
	 */
	public static void splitOff() throws IllegalThreadException,
			IncorrectThreadException {
		if (Thread.currentThread() instanceof MVC) {
			MVC thread = (MVC) Thread.currentThread();
			synchronized (mainThreadLock) {
				if (thread == mainThread) {
					log.debug("Splitting off...");

					MVC old = mainThread;
					old.running = false;
					mainThread = new MVC(old.threadCount + 1,
							old.currEventList, old.currEvent);
					old.currEvent = null;
					old.currEventList = null;
					log.debug("Starting next MVC thread");
					mainThread.start();
				} else {
					log.error("Can't split off when this isn't the main thread");
					throw new IncorrectThreadException();
				}
			}
		} else {
			log.error("Can't split off, we're not in the MVC thread.");
			throw new IllegalThreadException();
		}
	}

	/**
	 * Wait for all remaining events to dispatch
	 * 
	 * @param timeoutMillis
	 *            The maximum number of milliseconds to wait.
	 */
	public static void completeRemainingEvents(long timeoutMillis) {

		boolean fifoEmpty = false;

		long absTimeout = System.currentTimeMillis() + timeoutMillis;
		while (System.currentTimeMillis() < absTimeout) {
			synchronized (eventQueue) {
				fifoEmpty = (eventQueue.size() == 0);
			}

			if (fifoEmpty) {
				break;
			}

			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				break;
			}
		}
	}

	/**
	 * Stops the dispatch thread, dispatching any remaining events before
	 * cleanly returning. Thread automatically gets started when new events are
	 * dispatched
	 */
	public static void stopDispatchThread(long argTimeoutMillis) {
		synchronized (mainThreadLock) {
			mainThread.running = false;
			synchronized (eventQueue) {
				eventQueue.notify();
			}
			if ((mainThread != null) && (argTimeoutMillis > 0)) {
				try {
					mainThread.join(argTimeoutMillis);
				} catch (InterruptedException e) {
				}
				mainThread = null;
			}
		}

	}

	public static boolean isDispatchThreadRunning() {
		synchronized (mainThreadLock) {
			return mainThread != null
					&& (mainThread.running || mainThread.getState() == State.RUNNABLE);
		}
	}

	/**
	 * Manually starts the dispatch thread.
	 */
	public static void startDispatchThread() {
		synchronized (mainThreadLock) {
			if (mainThread == null) {
				mainThread = new MVC(0);
			}
			if (!mainThread.running) {
				if (mainThread.getState() == State.NEW) {
					mainThread.start();
				}
			}
		}
	}

	/**
	 * Sets the global event monitor, which is called before and after each
	 * event is dispatched.
	 * 
	 * @param argMonitor
	 * @see IGlobalEventMonitor
	 */
	public static void setGlobalEventMonitor(IGlobalEventMonitor argMonitor) {
		synchronized (monitorLock) {
			monitor = argMonitor;
		}
	}

	/**
	 * Gets the global event monitor. Default is {@link WarningMonitor}.
	 * 
	 * @return
	 * @see IGlobalEventMonitor
	 */
	public static IGlobalEventMonitor getGlobalEventMonitor() {
		synchronized (monitorLock) {
			return monitor;
		}
	}

	private volatile static EventMonitor guiMonitor = null;

	/**
	 * Convenience method to construct and show an {@link EventMonitor}. To have
	 * more control on how the {@link EventMonitor} is configured, you can just
	 * create it yourself and use
	 * {@link #setGlobalEventMonitor(IGlobalEventMonitor)} to have it be the
	 * global event monitor.
	 * 
	 * @return the {@link EventMonitor}.
	 */
	public static EventMonitor showEventMonitor() {
		if (guiMonitor == null) {
			synchronized (monitorLock) {
				guiMonitor = new EventMonitor(monitor);
				setGlobalEventMonitor(guiMonitor);
			}
		}
		guiMonitor.setVisible(true);
		return guiMonitor;
	}

	/**
	 * Hides the event monitor, if you had used {@link #showEventMonitor()}.
	 */
	public static void hideEventMonitor() {
		if (guiMonitor != null) {
			guiMonitor.setVisible(false);
		}
	}

	public static boolean isMainMVCThread() {
		MVC thread = (MVC) Thread.currentThread();
		return thread == mainThread;
	}

	@Override
	public void run() {
		running = true;
		log.info("MVC thread #" + threadCount + " starting up");
		while (running) {
			IEventListener listener;
			if (currEvent != null && currEventList != null
					&& currEventList.hasNext() && currEvent.isPropagating()) {
				synchronized (listeners) {
					listener = currEventList.next();
				}
				tryPreMonitor(currEvent);
				tryTrackEvent(currEvent);
				try {
					if (!listener.eventReceived(currEvent)) {
						if (isMainMVCThread()) {
							synchronized (listeners) {
								currEventList.remove();
							}
						} else {
							log.error("Cannot remove the listener " + listener
									+ ", as we've been split off");
						}
					}
				} catch (Exception e) {
					synchronized (monitorLock) {
						if (monitor != null) {
							try {// why do I have to do this? monitors shouldn't
									// throw
									// exceptions
								monitor.exceptionThrown(currEvent, e);
							} catch (Exception e2) {
								log.error(
										"Exception caught from event dispatch",
										e);
								log.error("Exception caught from monitor", e2);
							}
						} else {
							log.error("Exception caught from event dispatch", e);
						}
					}
				}
				tryPostMonitor(currEvent);
			} else {
				// grab next event
				try {
					synchronized (eventQueue) {
						if (eventQueue.isEmpty()) {
							eventQueue.wait();
						}

						if (!eventQueue.isEmpty()) {
							currEvent = eventQueue.poll();
						}
					}

					if (currEvent != null) {
						synchronized (listeners) {
							currEventList = listeners.get(currEvent.key)
									.iterator();
						}
					}
				} catch (Exception e) {
					log.error("Caught exception in dispatch thread", e);
				}
			}

		}
		mvcThreads.remove(this);
	}

	private void tryTrackEvent(MVCEvent argEvent) {
		if (argEvent instanceof ITrackable) {
			ITrackable event = (ITrackable) argEvent;
			if (event.getTrackingCategory() != null
					&& event.getTrackingAction() != null) {
				if (event instanceof ICustomTracker) {
					((ICustomTracker) event).getCustomTracker().trackEvent(
							event.getTrackingCategory(),
							event.getTrackingAction(),
							event.getTrackingLabel(), event.getTrackingValue());
				} else if (tracker != null) {
					synchronized (trackerLock) {
						tracker.trackEvent(event.getTrackingCategory(),
								event.getTrackingAction(),
								event.getTrackingLabel(),
								event.getTrackingValue());
					}
				} else {
					log.warn(
							"Event could not be tracked, as the tracker is null",
							event);
				}
			}
		}
	}

	private void tryPreMonitor(MVCEvent argEvent) {
		if (monitor != null) {
			synchronized (monitorLock) {
				try {
					monitor.beforeDispatch(argEvent);
				} catch (Exception e) {
					log.error("Exception caught from monitor", e);
				}
			}
		}
	}

	private void tryPostMonitor(MVCEvent argEvent) {
		synchronized (monitorLock) {
			if (monitor != null) {
				try {
					monitor.afterDispatch(argEvent);
				} catch (Exception e) {
					log.error("Exception caught from monitor", e);
				}
			}
		}
	}
}
